1. HTTP (REST API) =「寄信 / 叫外送」
最傳統的網路溝通方式,遵循一問一答的規則,你沒發問,伺服器就絕對不會理你。
2. WebSocket (串流) =「打電話 / 講對講機」
為了「即時互動」而生的技術,它會在雙方之間建立一條持久不中斷的專用通道。
| 特性 | HTTP (REST API) | WebSocket (串流) |
|---|---|---|
| 運作方式 | 錄音完整結束 ➔ 存檔 ➔ 一次性上傳 ➔ 等待回應 | 邊錄音 ➔ 邊送 ➔ AI 邊辨識邊吐字 |
| 延遲度 | 高 | 低 |
| 適用場景 | 語音輸入法、語音訊息、會議記錄轉寫 | 即時 AI 語音通話 |
| 實作難度 | 簡單 | 難 |
在 pubspec.yaml 加入 http 與 path_provider(用於存暫存檔):
dependencies:
http: ^1.2.1
path_provider: ^2.1.2
執行 flutter pub get
建立 lib/services/whisper_service.dart,負責將音訊檔案發送給 OpenAI:
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'dart:convert';
class WhisperService {
final String _baseUrl = 'https://api.openai.com/v1/audio/transcriptions';
/// 將本地音訊檔案上傳至 OpenAI Whisper API 轉換為文字
Future<String?> transcribeAudio(File audioFile) async {
final apiKey = dotenv.env['OPENAI_API_KEY'];
if (apiKey == null || apiKey.isEmpty) {
print('❌ 錯誤:未設定 OPENAI_API_KEY');
return null;
}
try {
// 1. 建立請求
var request = http.MultipartRequest('POST', Uri.parse(_baseUrl));
// 2. 設定 Request Headers
request.headers.addAll({
'Authorization': 'Bearer $apiKey',
});
// 3. 設定表單參數
request.fields['model'] = 'whisper-1';
request.fields['language'] = 'zh'; // 指定中文辨識,可以提升準確度(選填)
// 4. 附加音訊檔案
request.files.add(
await http.MultipartFile.fromPath(
'file',
audioFile.path,
),
);
print('🚀 正在發送音訊至 Whisper API...');
var streamedResponse = await request.send();
var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
String text = jsonResponse['text'];
print('✅ 辨識成功: $text');
return text;
} else {
print('❌ API 錯誤 [${response.statusCode}]: ${response.body}');
return null;
}
} catch (e) {
print('❌ 發送請求失敗: $e');
return null;
}
}
}
結合 Day 9 的錄音和今天的 Whisper API,錄音結束後自動存檔並翻譯:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'services/audio_recorder_service.dart';
import 'services/whisper_service.dart';
class AudioRecorderPage extends StatefulWidget {
const AudioRecorderPage({super.key});
@override
State<AudioRecorderPage> createState() => _AudioRecorderPageState();
}
class _AudioRecorderPageState extends State<AudioRecorderPage> {
final AudioRecorderService _recorderService = AudioRecorderService();
final WhisperService _whisperService = WhisperService();
bool _isRecording = false;
bool _isLoading = false;
String _transcribedText = '';
String? _currentAudioPath;
void _toggleRecording() async {
if (_isRecording) {
// 停止錄音
await _recorderService.stopRecording();
setState(() {
_isRecording = false;
_isLoading = true;
});
// 錄音結束後,將檔案送往 Whisper API
if (_currentAudioPath != null) {
final text = await _whisperService.transcribeAudio(File(_currentAudioPath!));
setState(() {
_transcribedText = text ?? '辨識失敗,請重試。';
_isLoading = false;
});
}
} else {
// 開始錄音 (儲存為 mp3 或 m4a 檔以供 HTTP 上傳)
final tempDir = await getTemporaryDirectory();
_currentAudioPath = '${tempDir.path}/temp_audio.m4a';
await _recorderService.startFileRecording(_currentAudioPath!);
setState(() {
_isRecording = true;
_transcribedText = '';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Day 10 - Whisper 語音轉文字')),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_isRecording ? Icons.mic : Icons.mic_none,
size: 80,
color: _isRecording ? Colors.red : Colors.grey,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _isLoading ? null : _toggleRecording,
style: ElevatedButton.styleFrom(
backgroundColor: _isRecording ? Colors.red : Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: Text(
_isRecording ? '停止並轉文字' : '開始錄音',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
const SizedBox(height: 30),
if (_isLoading) ...[
const CircularProgressIndicator(),
const SizedBox(height: 10),
const Text('AI 正在辨識語音中...'),
],
if (_transcribedText.isNotEmpty) ...[
const Text('辨識結果:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(_transcribedText, style: const TextStyle(fontSize: 16)),
),
]
],
),
),
);
}
}
🚀 正在發送... 與 ✅ 辨識成功。